import { existsSync } from 'fs'
import http from 'http'
import { writeFile, readFile } from 'fs/promises'
import { resolve } from 'pathe'
import { error, red, green, __dirname, info } from './index'
import { Theme, ThemeOptions } from '@formkit/theme-creator'
import { stylesheetFromTailwind } from '@formkit/theme-creator/stylesheet'
import { slugify } from '@formkit/utils'
import prompts from 'prompts'
import { createHash } from 'crypto'
import ora, { Ora } from 'ora'
import open from 'open'
import { parse as parseUrl } from 'url'
import { token } from '@formkit/utils'
import { getPort } from 'get-port-please'

interface BuildThemeOptions {
  semantic: boolean
  theme?: string
  variables?: string
  api?: string
  format?: 'ts' | 'mjs'
  outFile?: string
}

interface ServerResponse {
  status: 'success' | 'failed'
  message: string
}

const DEFAULT_THEME_API = 'https://themes.formkit.com/api'
const DEFAULT_THEME_EDITOR = 'https://themes.formkit.com'
const HAS_EXTENSION_RE = /\.(?:ts|js|mjs|cjs)$/

let themeListResponse: Response | null = null
let themes: Array<Record<string, any>> = []

async function fetchThemes() {
  themeListResponse = await fetch(`${DEFAULT_THEME_API}/themes`)
  themes = await themeListResponse.json()
}

export async function buildTheme(options: Partial<BuildThemeOptions> = {}) {
  if (!themes.length) await fetchThemes()

  let themeName = options.theme || ''

  if (!options.theme) {
    const generatedTheme = await localGeneratedTheme()

    if (generatedTheme) {
      const [code, path] = generatedTheme
      try {
        const [checksum, variables, theme] = extractThemeData(code)

        // determine if the theme is from themes.formkit.com
        const themeFromList = themes.find((t: any) => t.slug === theme)

        if (themeFromList) {
          const { editMode } = await prompts({
            type: 'confirm',
            message: `Found local theme file for ${theme}, edit this theme?`,
            name: 'editMode',
            initial: true,
          })
          if (editMode) {
            return await editTheme(path, code, checksum, variables, theme)
          }
        } else if (generatedTheme) {
          red(
            'It appears you have a local theme installed that is not part of the themes.formkit.com registry. By continuing your local theme file will be overwritten.'
          )
          const { confirm } = await prompts({
            type: 'confirm',
            message: 'Are you sure you want to continue?',
            name: 'confirm',
            initial: false,
          })

          if (!confirm) {
            return error('Aborting.')
          }
        }
      } catch (err) {
        console.error(err)
        console.info(
          `Detected local theme file but could not parse it. Perhaps it was edited or not generated by FormKit?`
        )
      }
    }
    const { theme } = await prompts({
      type: 'select',
      message: 'Select a theme',
      name: 'theme',
      choices: themes.map((theme: any) => ({
        title: theme.name,
        value: theme.slug,
        description:
          theme.description +
          (theme.darkMode ? ' 🌜' : '') +
          (theme.lightMode ? '☀️' : '') +
          ')',
      })),
    })
    themeName = theme
  }
  if (!themeName) error('Please provide a theme name or path to a theme file.')
  green(`Locating ${themeName}...`)

  const format = options.format || guessFormat()

  let theme = null
  // if the theme is in the themesList then grab it from the api
  const themeFromList = themes.find((t: any) => t.slug === themeName)

  // check if there is a local file matching the provided theme name
  const themeFilePath = determineFilePath(themeName) || themeName
  const localThemeFile = existsSync(resolve(process.cwd(), themeFilePath))
    ? await readFile(themeName, 'utf-8')
    : null

  if (themeFromList) {
    info('Fetching theme from themes.formkit.com')
    theme = await apiTheme(
      themeName,
      DEFAULT_THEME_API,
      options.variables ?? '',
      format === 'ts',
      options.semantic ?? false
    )
  } else if (localThemeFile) {
    info('Found local theme file')
    theme = await generate(
      await localTheme(themeName),
      options.variables,
      format === 'ts',
      options.semantic,
      themeName
    )
  } else {
    // try to load theme from locally installed package
    // using dynamic import
    info('Fetching theme from local npm packages')
    try {
      const { default: themeFunction } = await import(themeName)
      theme = await generate(
        themeFunction,
        options.variables,
        format === 'ts',
        options.semantic,
        themeName
      )
      green(`Found locally installed theme package: ${themeName}`)
    } catch (err) {
      console.error(err)
    }
  }

  if (theme) {
    const outFile =
      options.outFile || 'formkit.theme.' + (options.semantic ? 'css' : format)
    await writeFile(resolve(process.cwd(), outFile), theme)
    green(`Theme file written to ${outFile}`)
  } else {
    red(`Could not find theme: ${theme}`)
  }
}

async function editTheme(
  path: string,
  code: string,
  checksum: string,
  variables: string,
  theme: string
) {
  const codeWithoutChecksum = code.replace(
    `* @checksum - ${checksum}`,
    '* @checksum -'
  )
  const newChecksum = createHash('sha256')
    .update(codeWithoutChecksum)
    .digest('hex')
  if (newChecksum !== checksum) {
    const { confirm } = await prompts({
      type: 'confirm',
      message:
        'It appears you’ve edited your theme file. By continuing any changes made to your theme file will be lost. Are you sure you want to continue?',
      name: 'confirm',
      initial: false,
    })

    if (!confirm) {
      return error('Aborting.')
    }
  }
  await editMode(theme, variables, path)
}

/**
 * Use the public formkit url shortener.
 * @param url - A url to shorten
 */
async function shorten(url: string): Promise<string> {
  const res = await fetch('https://www.formk.it', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ url }),
  })
  const data = await res.json()
  return data.url
}

async function editMode(
  theme: string,
  variables: string,
  filePath: string
): Promise<void> {
  const nonce = token()
  const port = await getPort({
    portRange: [5480, 5550],
    host: 'localhost',
  })
  const url = `${DEFAULT_THEME_EDITOR}/editor/?theme=${theme}&variables=${variables}&n=${nonce}&p=${port}`
  const shortUrl = await shorten(url)
  info(`Open the theme editor at: ${shortUrl}`)
  const spinner = ora(`Edit in your browser.`).start()
  await open(url)
  let heartBeatTimeout: NodeJS.Timeout | undefined
  let server: http.Server | undefined
  let completedSuccessfully = false
  let savedAt = 0
  await new Promise<void>((resolve) => {
    server = http
      .createServer(async (req, res) => {
        const urlObj = parseUrl(req.url!, true)
        const path = urlObj.pathname as string
        const theme = urlObj.query.theme as string | undefined
        const variables = urlObj.query.variables as string | undefined
        const resData: ServerResponse = {
          status: 'failed',
          message: 'Incorrect token.',
        }
        if (path === `/${nonce}`) {
          clearTimeout(heartBeatTimeout)
          resData.status = 'success'
          if (theme && typeof variables !== 'undefined') {
            resData.message = 'updated'
            const themeCode = await apiTheme(
              theme,
              DEFAULT_THEME_API,
              variables,
              filePath.endsWith('.ts'),
              false,
              spinner
            )
            await writeFile(filePath, themeCode)
            spinner.text = `⚡️ theme updated.`
            savedAt = Date.now()
          } else {
            resData.message = 'listening'
            if (Date.now() - savedAt > 5000) {
              spinner.text = `waiting for${savedAt ? ' more' : ''} changes...`
            }
            heartBeatTimeout = setTimeout(() => {
              completedSuccessfully = true
              resolve()
            }, 3000)
          }
        }
        res.writeHead(resData.status === 'success' ? 200 : 400, {
          'Content-Type': 'application/json',
          'Access-Control-Allow-Origin': '*',
          Vary: 'Origin',
        })
        res.end(JSON.stringify(resData))
      })
      .listen(port)
  })
  server?.close()
  spinner.stop()
  if (completedSuccessfully) {
    info('Theme editor session complete, enjoy your theme 🎨')
    process.exit(0)
  }
}

function guessFormat() {
  return existsSync(resolve(process.cwd(), 'tsconfig.json')) ? 'ts' : 'mjs'
}

export async function generate(
  theme: Theme<ThemeOptions> | ReturnType<Theme<ThemeOptions>>,
  variables?: string,
  isTS?: boolean,
  semantic?: boolean,
  themeName?: string
): Promise<string> {
  if (typeof theme === 'function') {
    const vars = parseVariables(variables)
    themeName = theme.meta.name
    theme = theme(vars)
  }
  if (!themeName) error('Could not determine theme name.')
  info(`Loaded theme: ${themeName}`)
  const classes = theme.tailwind()
  if (semantic) return await stylesheetFromTailwind(classes)
  const classList: Record<string, Record<string, true>> = {}
  const globals: Record<string, Record<string, true>> = {}

  for (const input in classes) {
    for (const section in classes[input]) {
      const key = `${input}__${section}`
      const sectionClasses = classes[input][section]
        .split(' ')
        .reduce((acc, cur) => {
          acc[cur] = true
          return acc
        }, {} as Record<string, true>)
      if (input === '__globals') {
        globals[section] = sectionClasses
      } else {
        classList[key] = sectionClasses
      }
    }
  }

  const themeFile = `${
    isTS ? "import type { FormKitNode } from '@formkit/core'\n\n" : ''
  }/**
  * @privateRemarks
  * This file was generated by the FormKit CLI and should not be manually
  * edited unless you’d like to "eject" from the CLI’s ability to update it.
  *
  * @checksum -
  * @variables - ${variables}
  * @theme - ${slugify(themeName)}
  **/

 /**
  * This is the theme function itself, it should be imported and used as the
  * config.rootClasses function. For example:
  *
  * \`\`\`js
  * import { theme } from './formkit.theme'
  * import { defineFormKitConfig } from '@formkit/vue'
  *
  * export default defineFormKitConfig({
  *   config: {
  *     rootClasses: theme
  *   }
  * })
  * \`\`\`
  **/
 export function rootClasses (sectionName${isTS ? ': string' : ''}, node${
    isTS ? ': FormKitNode' : ''
  })${isTS ? ': Record<string, boolean>' : ''} {
   const key = \`\${node.props.type}__\${sectionName}\`
   const semanticKey = \`formkit-\${sectionName}\`
   const familyKey = node.props.family ? \`family:\${node.props.family}__\${sectionName}\` : ''
   const memoKey = \`\${key}__\${familyKey}\`
   if (!(memoKey in classes)) {
     const sectionClasses = classes[key] ?? globals[sectionName] ?? {}
     sectionClasses[semanticKey] = true
     if (familyKey in classes) {
       classes[memoKey] = { ...classes[familyKey],  ...sectionClasses }
     } else {
       classes[memoKey] = sectionClasses
     }
   }
   return classes[memoKey] ?? { [semanticKey]: true }
 }

/**
 * These classes have already been merged with globals using tailwind-merge
 * and are ready to be used directly in the theme.
 **/
const classes${
    isTS ? ': Record<string, Record<string, boolean>>' : ''
  } = ${JSON.stringify(classList, null, 2)};

/**
 * Globals are merged prior to generating this file — these are included for
 * any other non-matching inputs.
 **/
const globals${
    isTS ? ': Record<string, Record<string, boolean>>' : ''
  } = ${JSON.stringify(globals, null, 2)};
`

  const checksum = createHash('sha256').update(themeFile).digest('hex')
  return themeFile.replace(/@checksum -/, `@checksum - ${checksum}`)
}

function parseVariables(variables?: string): Record<string, string> {
  if (!variables) return {}
  return variables.split(',').reduce((vars, unparsed) => {
    const [key, value] = unparsed.split('=')
    vars[key] = value
    return vars
  }, {} as Record<string, string>)
}

function determineFilePath(fileName: string): string | undefined {
  const extensions = ['.ts', '.js', '.mjs', '.cjs']
  const paths = extensions.map((ext) => resolve(process.cwd(), fileName + ext))
  return getPath(paths)
}

function getPath(paths: string[]): string | undefined {
  const path = paths.shift()
  if (existsSync(path!)) return path
  return paths.length ? getPath(paths) : undefined
}

async function localGeneratedTheme(): Promise<[string, string] | undefined> {
  const path = determineFilePath('formkit.theme')
  if (path) {
    const code = await readFile(path, 'utf-8')
    return [code, path]
  }
  return undefined
}

async function localTheme(
  themeName: string
): Promise<Theme<ThemeOptions>> | never {
  const extensions = ['.ts', '.js', '.mjs', '.cjs']
  const paths = HAS_EXTENSION_RE.test(themeName)
    ? [resolve(process.cwd(), themeName)]
    : extensions.map((ext) => resolve(process.cwd(), themeName + ext))
  const path = getPath(paths)
  if (!path) error(`Could not find ${themeName}.`)

  const theme = (await import(path)) as { default: Theme<ThemeOptions> }
  if (typeof theme !== 'object' || !theme.default) error('Invalid theme file.')
  return theme.default
}

export function extractThemeData(
  theme: string
): [string, string, string] | never {
  const checksumStart = theme.indexOf('@checksum -')
  const variablesStart = theme.indexOf('@variables -')
  const themeStart = theme.indexOf('@theme -')
  if (checksumStart === -1 || variablesStart === -1 || themeStart === -1) {
    throw new Error('Unable to find checksum in theme file.')
  }
  const checksum = theme.substring(
    checksumStart + 12,
    theme.indexOf('\n', checksumStart)
  ) as string
  const variables = theme.substring(
    variablesStart + 13,
    theme.indexOf('\n', variablesStart)
  ) as string
  const themeName = theme.substring(
    themeStart + 9,
    theme.indexOf('\n', themeStart)
  ) as string
  return [checksum, variables, themeName]
}

async function apiTheme(
  themeName: string,
  endpoint: string,
  variables: string,
  isTS: boolean,
  semantic: boolean,
  spinner?: Ora
): Promise<string> {
  if (spinner) {
    spinner.text = `🔨 building ${themeName}`
  } else {
    info(`⚡️ building theme: ${themeName}`)
  }
  const res = await fetch(`${endpoint}/generate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      theme: themeName,
      variables,
      ts: String(isTS),
      semantic: String(semantic),
    }),
  })
  if (res.ok) {
    const code = await res.text()
    return code
  } else {
    error(`Could not generate theme — ${res.statusText}`)
  }
}
